feat(api): add scope-guarded profile write endpoints - #585
Conversation
The HTTP surface can list, read, and validate agent profiles but cannot
write them, so creating, editing, or deleting one still requires the CLI.
This adds the mutating half of the surface a management UI needs, plus an
authoring read that returns a profile exactly as stored.
Four routes:
POST /agents/profiles create from a supplied document
PUT /agents/profiles/{name} replace an existing local profile
DELETE /agents/profiles/{name} remove a local profile
GET /agents/profiles/{name}/source raw document, placeholders intact
Most of the diff sits below the handlers, because three of the four
contracts these routes need are properties of the service layer, and each
previously failed silently rather than loudly.
replace_profile in services/profile_store.py is update-only persistence.
write_profile(overwrite=True) is an upsert, so a PUT naming a built-in
would have written a local file that shadows a shipped profile on load,
which is the condition duplicated_in exists to report. replace_profile
raises ProfileNotFoundError instead, and because store_path resolves only
inside LOCAL_AGENT_STORE_DIR, that rejection happens at the service
boundary under the lock rather than in a handler pre-check.
locked_atomic_write gains must_exist, enforced in the same critical
section as overwrite. Checking existence outside the lock would be the
same TOCTOU shape that #543 removed from write_profile. The contradictory
pair overwrite=False with must_exist=True raises ValueError so a caller
bug fails fast instead of masquerading as FileExistsError.
A shared helper, _validate_profile_for_write, backs both POST and PUT so
the two cannot drift apart on either rule. It rejects any error-severity
finding with 400 and returns warnings to the caller rather than blocking
on them, and it requires the storage key and the frontmatter name to
agree: parsing treats the filename stem only as a fallback, so "name: foo"
stored as bar.md previously loaded as foo while being addressed as bar.
Every rejection carries one detail shape, {"message", "errors"}, so a
client iterates errors unconditionally instead of switching on the type of
detail. The helper parses the frontmatter once and calls
validate_frontmatter, rather than calling validate_profile_text and then
parsing a second time for the name check.
GET /agents/profiles/{name}/source exists because GET
/agents/profiles/{name} applies resolve_env_vars to the raw text before
parsing. An editor built on the resolved route would write substituted
values back, persisting a resolved secret from the managed environment
file into a plaintext profile.
DELETE requires cao:admin alone, matching six of the seven existing DELETE
routes; POST and PUT take cao:write or cao:admin like the other
non-destructive writes. No route is added to the scope-exemption set,
because these are real mutations, unlike the validate routes.
Also fixes a P3 reported on #575. Three malformed-but-parseable documents
raised TypeError out of profile_validator: an unhashable element in
allowedTools and a non-string role, both hashed against a set for
membership, and mixed-type mcpServers keys, compared while sorting schema
errors by path. TypeError is not caught by the route's ValueError handler,
so an endpoint whose job is reporting what is wrong with a document
answered some invalid documents with HTTP 500. All three now return 200
with valid: false and the schema error attached. The coverage gap was that
every malformed-input test used unparseable YAML and none used
parseable-but-wrong-typed.
Tests: 53 new across five files. Full suite 6,379 passed.
There was a problem hiding this comment.
I found two blocking defects: the new full-profile source read does not enforce OAuth scopes, and deletion does not use the lock required by the new update-only guarantee.
Correction: I previously described the read issue as normal agent behavior exposing secrets. That was too broad; the supported finding is the missing authorization check on the new HTTP source route.
The admin-only DELETE policy is internally consistent, but #510 still says DELETE accepts cao:write or cao:admin; please update the issue if admin-only is retained.
|
|
||
|
|
||
| @app.get("/agents/profiles/{name}/source") | ||
| async def get_agent_profile_source_endpoint(name: str) -> ProfileSourceResponse: |
There was a problem hiding this comment.
[P2] Apply a scope check to the new source read
This is an API authorization issue, not agent runtime behavior. The new /source route returns the complete stored profile document but has no require_any_scope dependency, so a request without a token still returns 200 when OAuth is enabled. Protect this authoring endpoint with write/admin, or at minimum the normal read scope. My earlier claim about normal agent behavior exposing API_TOKEN was too broad and has been removed.
| raise InvalidProfileNameError(f"Profile name '{name}' escapes the local store.") | ||
|
|
||
| try: | ||
| locked_atomic_write(target, content, overwrite=True, must_exist=True) |
There was a problem hiding this comment.
[P2] Use the same target lock when deleting
The must_exist check only protects this update if deletion also takes the same lock. delete_profile() still does an unlocked exists() followed by unlink(). I paused a replace after this check, let DELETE remove the file successfully, and then let the replace continue; os.replace() recreated the profile and both operations reported success. The new concurrency test deletes the file before starting two writers, so it does not exercise this race. Put the existence check and unlink under the same per-target lock as create and replace.
fanhongy
left a comment
There was a problem hiding this comment.
Summary
The write validation and focused tests are generally thorough, but the new surface has two blocking defects: raw profile source remains readable without authentication when OAuth is enabled, and DELETE does not participate in the lock on which PUT's update-only guarantee depends.
Findings
P1 - Require authentication on the raw source endpoint
src/cli_agent_orchestrator/api/main.py:2160
GET /agents/profiles/{name}/source has no require_any_scope(...) dependency. Authentication in this service is route-dependency based, so enabling OAuth does not protect this endpoint. I reproduced this with OAuth enabled, no Authorization header, and a raw malformed profile containing a confidential marker: the endpoint returned 200 and the marker. This is broader than the parsed profile path because _read_agent_profile_source returns exact content from local, provider, and extra stores even when parsing would fail.
Add a read gate such as Depends(require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN)), plus tests that a missing token is rejected and a read-scoped token is admitted.
P1 - Serialize DELETE with the lock used by update-only PUT
src/cli_agent_orchestrator/services/profile_store.py:182
replace_profile checks must_exist while holding the target lock, but delete_profile performs its exists()/unlink() at lines 210-212 without that lock. A DELETE can therefore unlink after PUT's existence check and before PUT's os.replace; PUT recreates the file and both requests report success. A deterministic barrier reproduction produced delete=success, put=success, and a surviving file containing the replacement. The added concurrency test deletes before starting its writers, so it does not exercise a concurrent deleter.
Make deletion acquire the same target lock for its existence check and unlink, preferably through a public atomic-delete helper, and add a barrier test that overlaps DELETE with PUT.
P2 - Match DELETE authorization to the issue contract
src/cli_agent_orchestrator/api/main.py:2132
Issue #510 specifies that profile deletion accepts cao:write or cao:admin, but this route requires admin only; the new test at test/api/test_scope_coverage.py:225 explicitly locks in a 403 for a write token. A client provisioned with the documented profile-management write scope can create and edit profiles but cannot complete the required delete workflow.
Use require_any_scope(SCOPE_WRITE, SCOPE_ADMIN), or formally revise the issue contract and affected client expectations before retaining the stricter policy.
P3 - Export the new public store operation
src/cli_agent_orchestrator/services/profile_store.py:143
replace_profile is a new public service function, but the module's __all__ list at lines 36-43 still exports every peer operation except this one. Explicit imports happen to work, while wildcard/public-surface consumers omit the update operation.
Add "replace_profile" to __all__.
Validation
- Re-ran all five changed test modules: 170 passed, 3 dependency deprecation warnings.
- Reproduced the PUT/DELETE race deterministically; both operations succeeded and the deleted profile was recreated.
- Reproduced the auth bypass with OAuth enabled and no bearer token; raw content returned with HTTP 200.
git diff --checkpassed, and the checkout remained clean at the reviewed SHA.- Acquisition metadata reports all 24 GitHub checks successful.
Addresses the review findings on #585 from @haofeif and @fanhongy, who reviewed independently and converged on the same two blocking defects. GET /agents/profiles/{name}/source carried no scope dependency, so enabling OAuth did not protect it. Authorization here is route-dependency based, and the route now takes require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), the same shape the ten already-guarded GET routes use. The pre-existing profile reads beside it stay ungated, following the split the #505 review settled: gate the newly added read, leave shipped routes alone rather than risk breaking an existing unauthenticated reader. Gating matters more on this route than on those siblings because _read_agent_profile_source returns the stored bytes verbatim across the local, provider, extra and built-in stores, including documents that fail to parse, whereas the parsed route can only return what the model accepts. test_scope_coverage.py could not have caught this: _MUTATING_METHODS is {POST, PUT, PATCH, DELETE}, so a new ungated GET is invisible to it. The guard therefore ships with a structural test asserting the dependency exists on the route object, mirroring _NEW_505_READ_ROUTES, plus enforcement tests that a scopeless token is refused and a read-scoped token is admitted. A status-code test would prove nothing, because auth is default-off and require_any_scope returns the full scope set when it is off. delete_profile performed an unlocked exists() then unlink(), which voided the update-only guarantee replace_profile advertises. A delete could land between an update's must_exist check and its os.replace: the delete succeeded, the update republished, and both callers were told they succeeded while a deleted profile was back on disk holding the replacement text. Reproduced deterministically with a barrier. locked_atomic_delete moves the existence check and the unlink inside the same per-target lock the writers use; unlink is already atomic, so the helper adds only the lock. It is safe against the flock-is-per-inode hazard because the lock file is not the target: _lock_path_for keys a file under LOCK_DIR by a hash of the resolved path and those are never unlinked. Two of these were self-inflicted in ways the tests did not catch. The replace_profile docstring described the update-versus-delete hazard accurately and then left deletion as the unlocked side of the pair, so it promised a guarantee the code did not deliver. The concurrency test was named for a concurrent deleter but deleted the file before its barrier and raced two writers, so it never overlapped a delete with a write. The test is renamed to what it exercises and a deterministic DELETE-versus-PUT overlap test is added alongside it. DELETE /agents/profiles/{name} moves from SCOPE_ADMIN alone to SCOPE_WRITE or SCOPE_ADMIN. Scopes are a flat set rather than a hierarchy: require_any_scope tests membership and get_current_scopes returns the token's claims unexpanded, so admin-only does not merely add admin access, it refuses a client holding exactly cao:write. That contradicted the contract published in #510 and would have left a profile-management credential able to create and edit a profile but not remove it. The six-of-seven precedent for admin-only DELETE still holds, but every one of those routes removes running or generated state, while the lone write-or-admin exception is the only content resource among them. A profile is an authored document, so it belongs with that one. Also exports replace_profile from profile_store's __all__, which listed every peer operation except the one this PR added. Tests: 10 new across three files. Full suite 6,389 passed.
fanhongy
left a comment
There was a problem hiding this comment.
Summary
There are no changes after the prior reviewed head; the checkout remains at 167d46ce619d65d65f2018fd752858bc60f911f3. The latest commit correctly gates the source route and serializes deletes with writes. I found one P2 in the aggregate write path and one P3 test-scope nit.
Findings
P2: Write validation can persist profiles that the runtime cannot load
src/cli_agent_orchestrator/api/main.py:1998
_validate_profile_for_write treats an empty JSON-Schema finding list as sufficient and the POST/PUT handlers then persist the document. However, the request contains YAML, whose mappings may have non-string keys, while JSON Schema assumes object keys are strings. For example, both of these documents produce no validator errors:
mcpServers:
1:
command: echotoolAliases:
1: ReadI reproduced both through POST /agents/profiles: each returned 201 and created the file, but parse_agent_profile_text then raised Pydantic ValidationError at mcpServers.1.[key] or toolAliases.1.[key]. The profile therefore appears to save successfully but cannot be read or launched, contradicting the route's guarantee that invalid profiles never reach disk. The schema limitation predates this PR, but making it the sole gate before the new persistence operation introduces the broken save path.
Validate the parsed metadata with the same AgentProfile model/load semantics before writing, or explicitly reject non-string YAML mapping keys for model fields that require string keys. Add an endpoint regression asserting these requests return 400 and leave no file.
P3: Rejection-shape test claims broader coverage than it provides
test/api/test_api_profile_surface.py:757
TestWriteRejectionShape says it covers “Every 400 from a write route,” but its loop only sends POST requests and only exercises validation-related failures. DELETE's unsafe-name path at src/cli_agent_orchestrator/api/main.py:2161 intentionally returns a bare-string detail, so the stated suite-wide contract is false even though the tested POST behavior is correct. Rename and document this as the POST/PUT validation-error shape, or parameterize the relevant POST and PUT cases and explicitly exclude service/DELETE errors.
Validation
- Confirmed the clean detached checkout and head SHA; there are zero commits after the prior reviewed SHA.
- Read the acquisition metadata, full diff, commit analysis, and context report, then inspected the changed implementation, callers, schema/model, and tests.
uv run --frozen python -m pytest -q test/api/test_api_profile_surface.py test/api/test_scope_coverage.py test/services/test_profile_store.py test/services/test_profile_validator.py test/utils/test_atomic_file.py: 180 passed, 3 dependency deprecation warnings.- Custom HTTP reproduction: both non-string-key profiles returned
201, persisted, and failedparse_agent_profile_textwith Pydantic key validation errors. git diff --check 135e7ff865226955ad67059181b4ea5939c9ae6e..HEAD: passed.- Acquisition also reports all 24 GitHub checks successful. Current
origin/mainstill produces a merge conflict inapi/main.py; the eventual conflict resolution will need separate validation.
Addresses the round-2 findings from @fanhongy on #585. A profile is submitted as YAML, which allows any scalar as a mapping key, but the format is described by JSON Schema, where object keys are strings by definition. jsonschema therefore reported nothing wrong with mcpServers: 1: command: echo so the write returned 201 and created the file, and parse_agent_profile_text then refused to load it with a Pydantic error at mcpServers.1.[key]. The profile saved and could not be read or launched, contradicting the route's guarantee that an invalid profile never reaches disk. Reproduced for both mcpServers and toolAliases before fixing. validate_frontmatter now walks the parsed document and reports any non-string mapping key as an error. Placed in the validator rather than only on the HTTP write path so every consumer agrees: otherwise cao profile validate and POST /agents/profiles/validate would call such a document valid while the write routes rejected it, and a UI that validates before saving would show a contradiction. Checking the key type generally rather than enumerating fields also covers YAML's other auto-typing: an unquoted 2026-01-01 key becomes a datetime.date, which fails the same way and is now caught. The schema limitation predates this PR. What this PR introduced was making that schema the sole gate in front of a new persistence operation, which turned a latent gap into a broken save path. Not fixed by validating through the AgentProfile model, which would catch strictly more. The write path persists unresolved text, so model-validating unresolved content would reject provider_init_timeout: ${TIMEOUT} that the runtime accepts after resolution, while model-validating resolved content would make acceptance depend on the server's environment. That tradeoff needs its own design rather than riding along here. Also unifies the 400 detail shape across the whole profile surface. The {"message", "errors"} dict was previously produced only inside _validate_profile_for_write, while four sites still returned a bare string: the service-raised InvalidProfileNameError on POST, PUT and DELETE, and the source route's ValueError. A caller therefore still had to switch on type(detail), which is what unifying the shape was supposed to remove, and DELETE was the reachable one because it has no body to validate first. The shape moves to a module-level _profile_write_rejection and all four sites use it. 404 and 409 keep FastAPI's conventional bare string: the status code discriminates and there are no findings to attach. TestWriteRejectionShape claimed to cover "every 400 from a write route" while its loop only sent POST and only validation failures, so the contract it documented was false. It is now parameterized across POST, PUT and DELETE, including the service-raised name error, so the gap fails a test instead of merely contradicting a docstring. This is the third assertion in this PR that promised more than the code delivered, after the replace_profile docstring and the concurrency test named for a deleter it never exercised. Tests: 13 new. Full suite 6,402 passed.
Brings the branch up to c64c9fa, three commits on since this PR's base: #526 (durable workflow run journal), #545 (codex handoff extraction), and #539 (claude_code startup prompt). One conflict, in api/main.py's typing import: this branch added Sequence for the write-rejection helper and #526 added AsyncIterator for its SSE events route. Resolved to the union of both, then reformatted by black. docs/api.md and test/api/test_scope_coverage.py auto-merged. Merged rather than rebased so the two review-cycle commits keep their reviewed SHAs and no force-push is needed. Verified on the merged tree: 216 passed across the five modules this PR touches, test_scope_coverage.py fully green at 33 passed, and black and isort clean over 548 files. Worth noting for anyone following the review thread: the ten test_scope_coverage failures that appeared locally against 0903561 are fixed upstream by c64c9fa and no longer reproduce on pristine main.
|
Thanks, both confirmed and fixed. I reproduced each before changing anything. P2, write validation can persist profiles the runtime cannot loadConfirmed exactly as described. Both shapes produce zero validator findings, The mismatch is format-level: the body is YAML, which allows any scalar as a
It lives in the validator, not only on the write path. Otherwise It checks the key type generally rather than enumerating the object-valued On your first suggestion, validating the parsed metadata through Endpoint regressions added as you asked: 400 with nothing on disk for P3, rejection-shape testCorrect, and worse than one route. Enumerating every 400 across the four routes Only the validation path produced the dict. So the docstring was false in four I took the other branch of your suggestion and made the claim true rather than Worth naming the pattern rather than only the instance: this is the third Merge, since you flagged it needs separate validationMerged Verified on the merged tree:
One aside in case you hit it too: 71 net new tests, rebaselined on |
There was a problem hiding this comment.
Correction after consistency re-check: the earlier source-route authorization and PUT/DELETE race findings are fixed. Two P2 issues remain: the write gate rejects URL-based MCP entries that CAO explicitly supports, and the new recursive YAML-key check can do exponentially growing work on the unauthenticated validation endpoint. I have reclassified the typed-placeholder case as a non-blocking, pre-existing validator limitation rather than a new P2 in this PR.
| except Exception as exc: | ||
| _reject(f"Profile could not be parsed and was not written: {exc}") | ||
|
|
||
| findings = validate_frontmatter(parsed.metadata) |
There was a problem hiding this comment.
[P2] Accept the URL-based MCP servers CAO already supports
This new hard gate rejects a profile containing a standard remote MCP entry such as docs: {type: http, url: https://mcp.example.invalid/mcp}. parse_agent_profile_text accepts it, and resolve_mcp_server_config explicitly preserves commandless URL/transport entries, but this schema check reports 'command' is a required property, so both POST and PUT return 400. This prevents users from creating or editing profiles for a supported MCP form. Please allow URL/SSE entries as well as command-based entries before making this schema a blocking write check.
| findings = validate_frontmatter(parsed.metadata) | ||
|
|
||
| errors = [f for f in findings if f.severity == "error"] | ||
| if errors: |
There was a problem hiding this comment.
Correction — non-blocking follow-up
The behavior above is reproducible, but I should not have classified it as a new P2 in this PR. The shared validator already rejects placeholders in typed schema fields, this limitation was explicitly disclosed before this re-review, and typed placeholders are not a documented profile form. Placeholder-aware validation is worth defining separately, but it should not block this write-endpoint PR.
| elif isinstance(value, list): | ||
| for index, child in enumerate(value): | ||
| child_path = f"{path}.{index}" if path else str(index) | ||
| findings.extend(_non_string_key_findings(child, child_path, _depth + 1)) |
There was a problem hiding this comment.
[P2] Bound traversal of repeated YAML aliases
The depth limit does not limit the total work here because yaml.safe_load represents every alias as another reference to the same Python object, and this recursion walks that object again for every reference. A 529-byte doubling alias chain at depth 20 took about 1.5 seconds in this function, with each extra level roughly doubling the time. Since the unauthenticated async /agents/profiles/validate route calls this synchronous traversal directly, small requests can stall the server for an unbounded time. Please track already-visited container identities, reject repeated/cyclic aliases, or enforce a global node budget rather than relying only on depth.
Two findings from @haofeif on the write gate, in opposite directions: it could be stalled by a valid document, and it rejected a valid one. The non-string mapping key check added last round walked the parsed document with only a recursion depth cap. That bounded the wrong dimension. yaml.safe_load resolves every alias to another reference to the *same* object, so N chained anchors that each reference the previous one twice leave memory linear while an unmemoized walk traverses the graph 2**N times. Depth was never the problem; revisiting shared objects was. A 640-byte, schema-valid body took ~1s locally and doubled per added level, against ~0s for the jsonschema step beside it, so the amplification was introduced entirely by that walk. It was reachable without credentials. POST /agents/profiles/validate is in the scope-exemption set, so it answers even when OAuth is configured, and it is declared async, so a synchronous CPU-bound walk on its thread stalls the event loop for every other request rather than only the caller's own. The walk now skips any container it has already visited, keyed on id(). That removes the amplification at its source and costs no coverage: a shared subtree cannot hold a different set of keys on a second visit, so one finding per offending key is the correct output, reported at the first path reaching it. Comparing identity is sound here specifically because every value stays reachable from the document for the duration of the walk, so nothing can be collected and no id recycled midway; the code says so rather than leaving it as a trap for a later reader. Identity memoization does not bound a document that is merely enormous, so explicit ceilings on total values and nesting depth remain, both ~1000x the largest bundled profile. Exceeding either now yields an error finding. Previously the depth cap returned silently, which reported an unchecked document as valid. Separately, agent_profile.schema.json required "command" on every mcpServers entry, while resolve_mcp_server_config documents command-less entries shaped {"type": "http", "url": ...} as passing through untouched, and providers forward them to their own MCP config. Because this PR made that schema the blocking gate in front of persistence, an incomplete description became a broken save path: POST and PUT returned 400 for a form CAO supports. Entries now require command or url via anyOf, with url declared so the field is described rather than merely tolerated by an absent additionalProperties: false, and so a wrong type is a finding. An entry defining neither is still rejected. That is the mirror image of the round-1 finding on the same decision: that one let unloadable profiles reach disk, this one blocked loadable ones. Both came from treating an incomplete schema as a blocking gate. The anyOf rejection message is jsonschema's generic "is not valid under any of the given schemas". It names the exact entry and path, but not which key is missing. Left as is rather than adding message-rewriting machinery to the validator; noted in the PR's known gaps. Tests: 20 new. A 40-level bomb (2**40 paths, under 1500 bytes) now validates in ~0.0001s, and a bad key inside a shared subtree is asserted to appear exactly once, which pins the memoization without depending on a clock. Legitimate anchor reuse still validates clean, both ceilings are asserted to reject rather than fall silent, and a url-based profile is asserted to survive the write, the profile parse, and MCP resolution with its transport intact. Full suite 6,711 passed.
Brings in #604 (flow frontmatter injection), #606 (read scope on sensitive read endpoints) and #613 (agent-step terminal cleanup). One conflict, in the signature of GET /agents/profiles/{name}. #606 added a require_any_scope dependency there; this branch had added a docstring note pointing editors at the source route. Resolved to both. #606 also settles a question this branch had answered the other way. Round 1 of review gated the new source route while deliberately leaving the already-shipped profile reads beside it ungated, on the #505 precedent that tightening a shipped route could break an existing unauthenticated reader. #606 has now gated those siblings, so that asymmetry is gone and the rationale recorded on the route no longer describes the code. Rewritten to say what is now true, and to point at the registry below. GET /agents/profiles/{name}/source is added to #606's _GATED_ROUTES and its sample requests, so it is covered by that file's enforcement tests rather than only by this branch's structural one. It belongs there on #606's own definition: it is a sensitive read, arguably more so than the parsed route beside it, since it returns stored bytes verbatim from the local, provider, extra and built-in stores, including documents that fail to parse. That list is maintained by hand, by design, so a new route does not join it automatically. Verified at this commit, with the worktree's own source on PYTHONPATH so the checkout under test is the one being imported: 300 passed across the profile, atomic, scope and read-gating modules, no failures.
A strawman of the previous commit found that its headline claim was only half true. It closed a CPU amplification in this module's own key walk and left a larger allocation vector open on the same unauthenticated route. jsonschema builds every error message eagerly, interpolating repr of the offending instance. YAML aliases resolve to repeated references to one object, so a document whose expansion is exponential in its byte count produces an error message that is too: a 651-byte body with 20 anchor levels that trips a single type error yielded a 25 MB message, 101 MB at 22 levels, doubling per level, which puts ~26 levels in the gigabytes. That string is then serialised into the response. Allocation is the ceiling, not CPU, and no care taken in this module's own traversal avoids it. Confirmed pre-existing rather than introduced here: byte-identical numbers on pristine origin/main, where the route was already scope-exempt and already async. Fixed here anyway. It is the same route and the same class of bug as the finding it sits behind, and shipping "the traversal is bounded" while a larger vector remains on that endpoint would be the same overstatement this PR has now corrected four times. _structural_bound_finding counts the values a fully expanded rendering would contain, memoized on id() so the count stays linear in distinct objects and capped so an enormous document costs no more to reject than a borderline one. It runs before the key walk and before jsonschema, and validate_frontmatter returns as soon as it reports, because continuing would pay exactly the cost the ceiling exists to avoid. Expressing the ceiling as expanded size rather than traversal steps also fixes a message that lied. The previous commit decremented a budget per edge traversed while naming it _MAX_WALK_VALUES and reporting "holds more than 20000 values", so an 84 KB document holding four values, one of them aliased 21,000 times, was rejected for holding twenty thousand. It now reports what is actually counted: that document does expand to ~21,004 values. Reachable inside the 256 KB content cap, so not hypothetical. The walk loses its own size budget as redundant. With identity memoization it is linear in the document's distinct containers, and it now only runs on documents the ceiling accepted. Two ratios stated separately, because they are not the same: against the largest bundled profile's 23 expanded values and depth of 3, the ceilings sit ~870x and ~21x above it. The previous commit's comment said "~1000x" of both, which was wrong by ~50x for depth. Also documents, rather than leaves implicit, that the two halves of validate_frontmatter report shared values differently. A shared value that is schema-invalid yields one finding per referencing path, since jsonschema does not memoize; a shared non-string key yields exactly one. Both are right, but a client rendering findings should not assume one convention. Tests: the anchor bomb is now rejected rather than accepted, so the assertions become deterministic. Rejection is asserted on the error message and on response size (under 2 KB for a body that previously returned 25 MB) rather than on elapsed time, which means a regression fails instead of hanging until CI's job timeout, as the earlier timing-only assertions would have. The memoization proof moves to 10 anchor levels, 1024 paths to one bad key, which stays under the ceiling so the walk still runs and one finding still proves dedup. 98 net new tests. Full suite 6,795 passed.
Brings in #608 (bearer token on the terminal WebSocket), #622 (replace a vulnerable image-size dependency) and #596 (xAI Grok CLI provider). No conflicts, despite #596 touching four files this branch also changes. Its schema addition, grokNativeWorkflows, sits well below the mcpServers block edited here, and its two new endpoint tests land in classes above the ones this branch added, so both sides applied cleanly. Checked rather than assumed, since a clean auto-merge is not the same as a correct one: - The mcpServers command-or-url anyOf and its url property both survive, and the incoming grokNativeWorkflows property is present alongside them. - The schema/model parity test from #575 still passes, which it would not if only one side of #596's field had landed. - #596's own profile validates clean through the expansion ceiling added here, and its assertion on the exact shape of the schema endpoint response still holds. - The ratios documented on that ceiling are unchanged. They are stated against the largest bundled profile, so a new provider shipping a profile would have invalidated them; #596 ships none, and developer.md is still the largest at 23 expanded values and depth 3. - docs/agent-profile.md took both descriptions without duplication. The local uv.lock drift that every commit on this branch has excluded had to be stashed to take the merge, because #596 adds psutil and types-psutil to that file. Those additions survive in the working tree; the drift itself is regenerated by every `uv run`, which is why it keeps reappearing, and it still does not belong in this branch. Verified on the merged tree: 347 passed across the profile, atomic, scope and read-gating modules, and 6,939 passed on the full suite. black and isort clean across 554 files. 98 net new tests, re-measured per file against this base.
P2a: URL-based MCP entries@haofeif , You were right, and the codebase documented the contradiction itself. Your exact example now validates and survives the round trip:
Two limits worth naming rather than leaving you to find:
The rejection message for an entry with neither key is jsonschema's generic P2b: bounded traversal, and the vector behind itYour mechanism was exactly right and I reproduced it independently before changing anything: 640 bytes at 20 levels, ~1s, doubling per level, against ~0s for the jsonschema step beside it. Close enough to your 529 bytes / 1.5s to be the same thing. The depth cap bounded the wrong dimension. Depth was never the problem, revisiting shared objects was. Of your three suggested remedies I took the first, tracking visited container identities, and added a global budget. I deliberately did not reject repeated or cyclic aliases. Anchor reuse is ordinary YAML and rejecting it would break documents that are fine: Both are pinned by tests so a later "fix" can't satisfy the bound by refusing anchors. Then I strawmanned that fix and found the larger half. jsonschema builds every error message eagerly, interpolating
2x per level, so ~26 levels reaches gigabytes, and that string gets serialised into the response. Allocation is the ceiling rather than CPU, and nothing done inside my traversal avoids it. On the 22-level document the identity-memoized walk costs 0.00003s while This one is pre-existing, not introduced by this PR. Verified against current The route was already scope-exempt and already I fixed it here anyway. It's the same route and the same class of bug as the finding it sits behind, and telling you "the traversal is bounded" while a larger vector remained on that endpoint would have been half an answer. If you'd rather it went to its own issue against The final shape is one ceiling on expanded size, ahead of both the walk and the schema step, memoized on Milliseconds, and flat as levels grow. Two corrections to my own first attempt at thisBoth were in the commit that fixed your finding, so they'd have landed under your name if you hadn't looked twice. The budget decremented per edge traversed while being named The comment called both ceilings "~1000x" the largest bundled profile. Against its 23 expanded values and depth of 3 they are ~870x and ~21x. Wrong by roughly 50x for depth. Stated separately now. I also documented something I'd left implicit: the two halves of Tests for all of this are deterministic rather than timed. The bomb is now rejected rather than traversed quickly, so the assertions are on the error message and on response size, under 2 KB for a body that previously returned 25 MB. If the memoization regresses, a test fails instead of hanging to the job timeout, which is what the earlier timing-only assertions would have done. P2cNoted, and thanks for revisiting it. No action taken. The Known gaps entry on placeholder-aware validation stays as it was, and I agree it wants its own definition rather than riding along here. One interaction with #606#606 gated I also added Verification
The PR description is rewritten with a Review round 4 section carrying the measurements above. |
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed current head 8bda8ec06ed987a855d768d340bae43ac4bf5f1f from scratch against current main. The source-read scope gate, DELETE/PUT serialization, and URL/SSE MCP round-trip fixes now hold. Two blocking gaps remain in the new structural guard, detailed inline: scalar aliases can still amplify a small unauthenticated validation request into gigabytes of schema output, and cyclic YAML is accepted and persisted even though the provider JSON path cannot serialize it.
| def expanded(value: object, depth: int) -> int: | ||
| nonlocal too_deep | ||
| if not isinstance(value, (dict, list)): | ||
| return 1 |
There was a problem hiding this comment.
[P1] Bound rendered scalar bytes, not only value occurrences
Every scalar returns 1 here, but the downstream cost this guard is meant to bound is repr(instance), which includes the scalar bytes again for every alias. On this head, a 22,136-character profile containing one 2,048-character &s scalar and 5,000 aliases to it in a schema-invalid toolsSettings list passes _structural_bound_finding; unauthenticated POST /agents/profiles/validate returns a 10,260,109-byte response. More importantly, a 250,088-character request (below the 262,144 cap) with a 190,000-character scalar and 15,000 aliases is also accepted at roughly 15,006 counted values, while the one jsonschema instance representation has a 2.85 GB lower bound. Because this route is scope-exempt and runs synchronously inside an async handler, one request can still exhaust process memory and block every request. Please include scalar byte length in the expansion budget, or otherwise bound/sanitize schema error rendering before it is serialized.
| identity = id(value) | ||
| if identity in memo: | ||
| return memo[identity] | ||
| memo[identity] = 1 # Cycle guard, in force while this container counts. |
There was a problem hiding this comment.
[P3] Reject cyclic graphs instead of counting a back-edge as finite
The provisional memo entry makes a self-reference contribute 1, and the new regression test consequently treats this document as valid:
name: cyc
description: cyclic
toolsSettings: &c {self: *c}POST /agents/profiles returns 201 and persists it, but the normal Kiro materialization path passes profile.toolsSettings into KiroAgentConfig and model_dump_json() at install_service.py:379, which raises PydanticSerializationError: Circular reference detected (id repeated). The write gate therefore still succeeds for a profile the runtime cannot materialize. A cycle also has no finite fully-expanded size. Please track in-progress identities separately from completed memo entries and return a validation error on a back-edge, while retaining memoization for ordinary shared acyclic subtrees.
|
Severity correction after impact/likelihood triage: the scalar-alias amplification is P1 because one unauthenticated, under-limit request can force multi-gigabyte allocation and take down the server. The cyclic-YAML finding is P3 / non-blocking because it requires deliberately self-referential input and affects only that profile. The Changes Requested verdict remains because the P1 is independently blocking. |
Brings in #497, which surfaces working_directory and agent_profile on list_sessions. No conflicts, and no overlap at all with the files this branch touches. Verified at this commit rather than assumed: 6,961 passed on the full suite, with the 62 local failures confined to the same agui, telemetry and no_ffi_guard modules that fail on a clean checkout here.
Two P2s from @haofeif on 8bda8ec, both in the guard the previous commit added, both reproduced before changing anything. The guard counted value occurrences. The cost it exists to bound is repr(instance), which jsonschema interpolates into every error message it builds eagerly. Every scalar contributed 1 regardless of length, so aliasing one large scalar multiplied content without moving the count: a 22,103-byte request holding a 2,048-character scalar referenced 5,000 times was accepted and returned a 10,260,024-byte response, and a 250,055-byte request holding a 190,000-character scalar referenced 15,000 times was accepted at roughly 15,006 counted values while the single instance rendering had a 2.85 GB lower bound. Both arrive under the 256 KB cap on content, on a route that is scope-exempt and synchronous inside an async handler. The ceiling is now in rendered bytes, the unit that cost is actually paid in. A scalar contributes its own rendered length, a mapping its keys and separators, a sequence its separators, and each is charged at every reference while being measured once. Bound set at 1 MB, about 3.8x the largest request that can arrive, since a document with no aliasing renders to roughly its own size. The largest bundled profile renders to 485 bytes. Worth stating plainly: this is the second time this function has bounded the wrong dimension. It capped depth when the cost was repeat visits, then capped occurrences when the cost was bytes. So the fix does not rest on having picked the right dimension a third time. Schema findings are now length-capped before they reach a response, which also bounds the case the ceiling does not, where a document within it trips errors on several fields that each render their own subtree. Separately, cycles were treated as finite. The provisional memo entry gave a back-edge a size of 1, so a self-referential document counted as small and was accepted, and the previous commit's test asserted it was valid. It is not: a cycle has no finite rendering, and the Kiro materialization path raises PydanticSerializationError: Circular reference detected when it serializes toolsSettings, so the write gate was persisting a profile the runtime cannot install. That is the same failure mode as the non-string key rule two rounds earlier. In-progress identities are now tracked separately from completed ones, so a back-edge is an error while ordinary shared acyclic subtrees stay memoized and still validate clean. Declining to reject cycles was a deliberate choice recorded in the round-4 reply, on the grounds that ordinary anchor reuse must keep working. That reasoning holds for acyclic reuse and was wrong for cycles; both are now handled separately rather than together. Tests: the self-referential case inverts from accepted to rejected, with a companion asserting the runtime genuinely cannot serialize it so the rule reads as a reason. Both reported scalar cases are pinned by response size. Two mechanisms that were not reported are covered after probing for them: cycles reached through sequences rather than mappings, and merge keys, which copy a target's entries into each merging mapping and so multiply content by a different route. Ordinary merge keys and a large unaliased document are both asserted to still pass, so the bound cannot be satisfied by rejecting size or aliasing outright. Full suite 6,961 passed.
Include the truncation marker within the 2,000-character finding limit instead of appending it after the retained content. Add an exact-boundary regression test.
|
Both fixed, and both were mine. I reproduced each on Correction to my round-4 reply@haofeif , I said I had deliberately declined your "reject repeated/cyclic aliases" suggestion, and I cited the self-referential document validating clean as evidence that this was the right call. That reasoning holds for acyclic reuse and was wrong for cycles, and you were right to have grouped them in the original suggestion. They are handled separately now. Cycles were treated as finite, and persistedThe provisional memo entry gave a back-edge a size of 1, so the document counted as small: So Fixed in the shape you described. In-progress identities are tracked separately from completed ones, so revisiting a container still being measured is a back-edge and an error, while revisiting a finished one is ordinary sharing and stays memoized. Acyclic reuse still validates clean, which is the property I was trying to protect and which a test now holds me to. While probing it I found the back-edge does not have to be a mapping, so The guard counted the wrong unitYour mechanism is exactly right: every scalar returned 1 regardless of length, while the cost is
My first figure differs from your 10,260,109 by 85 bytes, which I put down to formatting in how I built the document rather than a different effect. The ceiling is now in rendered bytes, the unit the downstream step actually pays in. A scalar contributes its own rendered length, a mapping its keys and separators, and a sequence its separators. The guard computes each object's The part I want to name rather than let you find. This is the second time this function has bounded the wrong dimension. Round 3 capped depth when the cost was repeat visits; round 4 capped occurrences when the cost was bytes. Two wrong dimensions in a row is a reason not to trust that the third is right, so I also took your alternative and bounded the output: schema findings are length-capped before they reach a response. To be clear about what that does and does not do, it is a backstop and not the fix. jsonschema builds the message inside Those probes found one P3 in my first backstop: it retained 2,000 characters and then appended a 35--38 character marker. The marker is now budgeted inside the 2,000-character total, and an exact-boundary test pins that contract. Across the reviewer cases and additional graph shapes, peak memory stayed below 15 MB; no P1/P2 bypass remained. Also not reported and found while probing: YAML merge keys are a second alias mechanism. What is pinned so this does not regressThe self-referential test inverts from accepted to rejected, and a companion asserts the runtime genuinely cannot serialize it, so the rule reads as a reason rather than a restriction. Both of your scalar cases are asserted on response size. Cycles are parameterized over the mapping, sequence, and sequence-to-mapping shapes. A large unaliased document and ordinary merge keys are both asserted to still pass, so the bound cannot be satisfied by rejecting size or aliasing outright. Focused profile-validator/API suite: 139 passed. Full suite: 7,023 passed, with the same 62 known local-only failures outside this surface. 111 net new tests, measured per file with |
| # the validator. | ||
| validator = Draft202012Validator(load_profile_schema()) | ||
| for error in sorted(validator.iter_errors(metadata), key=lambda e: list(e.path)): | ||
| for error in sorted(validator.iter_errors(metadata), key=lambda e: [str(p) for p in e.path]): |
There was a problem hiding this comment.
[P1] Bound the aggregate number and size of findings
_MAX_FINDING_CHARS caps each message, but this sorted(iter_errors(...)) still eagerly materializes every schema error, after which the route duplicates all of them into response models and JSON. A 260,051-character request (under the 262,144 cap) can be built with:
items = ",".join(["0"] * 130_000)
content = f"---\nname: t\ndescription: d\nallowedTools: [{items}]\n---\n\nB.\n"Against this exact head, the scope-exempt endpoint returned 130,000 findings and an 11,328,918-byte body. In an isolated uvicorn process, server HWM rose from 107,420 KiB to 580,732 KiB, RSS remained at 327,956 KiB afterward, and the request took 3.199 s; a concurrent small schema GET waited 3.087 s because this synchronous validation runs in the async handler. Thus one unauthenticated, permitted-size request can exceed a 512 MiB service budget and stall every client, despite the per-message cap. Please enforce a small aggregate finding/response budget while iterating (with an omitted-findings marker), before sorting and serializing the full error stream.
There was a problem hiding this comment.
A concrete fix I recommend:
- Add one aggregate budget used by every finding source (schema errors, non-string keys, and
allowedToolswarnings), for example 100 returned findings / 100 KB of message+path text. The warning loop can otherwise reproduce the same shape with 130,000 unknown string tools. - Reserve one slot for a final
Additional validation findings omittederror, and stop each producer as soon as the budget is full. Do not exhaust the iterator to count the omitted entries. - Bound schema iteration before sorting; slicing
sorted(...)afterward retains the current allocation:
from itertools import islice
remaining = max(0, _MAX_FINDINGS - len(messages) - 1)
errors = list(islice(validator.iter_errors(metadata), remaining + 1))
omitted = len(errors) > remaining
for error in sorted(errors[:remaining], key=_error_path_key):
path = _capped_path(".".join(str(p) for p in error.absolute_path) or "(root)")
if not add_within_byte_budget(
ValidationMessage("error", _capped(error.message), path)
):
omitted = True
break
if omitted:
messages.append(
ValidationMessage("error", "Additional validation findings omitted.")
)add_within_byte_budget should also be used inside _non_string_key_findings and the allowedTools loop, so those helpers never build an unbounded intermediate list. Cap path separately because _MAX_FINDING_CHARS currently covers only the message.
I would pin this with endpoint regressions for both 130,000 integer entries and 130,000 unknown string entries, asserting a fixed maximum finding count and response size, plus a unit test proving iter_errors is consumed at most remaining + 1 times.
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed current head aebc405 against all prior iterations. Earlier blockers remain fixed, but one P1 remains.
[P1] Bound aggregate validation findings before materialization — src/cli_agent_orchestrator/services/profile_validator.py:374-397 still eagerly sorts every JSON Schema error and appends one warning per unknown allowedTools entry without a shared count or byte budget. On this head, 10,000 entries produced 10,000 findings and a 1,750,029-byte response; the scope-exempt synchronous endpoint therefore remains vulnerable to request-sized amplification and event-loop or memory exhaustion. Stop each producer under a shared aggregate budget and reserve an omission marker.
Fixed in the latest update. Aggregate validation findings now use a shared budget of 100 findings and 100 KB across all producers. Schema errors are bounded before sorting/materialization, unknown allowedTools warnings stop at the shared limit, and capacity is reserved for an omission marker. I also added large-input endpoint regressions, a real-validator laziness test confirming only the bounded prefix is consumed, and cross-process determinism coverage. The focused service/API suite passes with 147 tests. |
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed at fa01a94 (was aebc405).
The only new commit is fa01a94, a merge of origin/main. I diffed the PR's own contribution against the merge base and confirmed no PR-authored file changed — git diff aebc4051 fa01a941 -- services/profile_validator.py services/profile_store.py is empty. So the state of the review is unchanged from aebc405: the aggregate-findings P1 is still open, and I'm keeping Changes Requested for that one finding alone.
Everything else I've raised across this PR is confirmed fixed, and I re-verified both of the still-open threads at this head rather than assuming:
profile_store.py:193— delete now takes the target lock.delete_profileroutes throughlocked_atomic_delete, sharing the lock withreplace_profile, which closes the delete-during-replace race I reproduced. Thread's still open on GitHub; safe to resolve.agent_profile.schema.json— URL-based MCP servers are accepted. ThemcpServersitem now carriesanyOf: [{required:[command]}, {required:[url]}]. Verified end-to-end against a running server:{docs: {type: http, url: https://mcp.example.invalid/mcp}}returns{"valid":true,"messages":[]}. Also resolvable.- The
api/main.py:2463thread is my own retraction and needs nothing.
One thing worth calling out about the merge. It brings in OriginCheckMiddleware from #605, and I want to head off the idea that it mitigates the P1. It doesn't — the guard is if origin and not is_http_origin_allowed(...) (api/main.py:1421), and the docstring is explicit that non-browser clients "send none, so a present-but-untrusted Origin is exactly the browser-only signal this rejects". A curl/requests POST carries no Origin and passes straight through. The endpoint is also still deliberately outside SCOPE_WRITE (api/main.py:2352-2355), so it remains reachable unauthenticated.
Re-measured the P1 at this head against a real uvicorn process rather than TestClient — details on the thread. Short version: a 260,051-byte request (under the declared 262,144 cap) produces 130,000 findings and an 11,328,918-byte response, 469 MB peak Python allocation, and stalls every other client for the duration.
A note on method, since it nearly caught me out: TestClient defaults to Host: testserver, which TrustedHostMiddleware now rejects with a bare 400 Invalid host header. My first run looked like the P1 had been fixed. Anything probing this surface needs headers={"host": "127.0.0.1:8000"} to get past it.
The concrete fix I recommended on review 4966891645 still applies unchanged — one shared budget across all three finding producers, a reserved omission marker, and islice before the sorted() rather than a slice after it. Happy to look again as soon as that lands; nothing else is holding this up.
Ran test/services/test_profile_validator.py, test/services/test_profile_store.py, test/api/test_api_profile_surface.py, test/utils/test_atomic_file.py, test/api/test_scope_coverage.py at this head: 243 passed.
| # the validator. | ||
| validator = Draft202012Validator(load_profile_schema()) | ||
| for error in sorted(validator.iter_errors(metadata), key=lambda e: list(e.path)): | ||
| for error in sorted(validator.iter_errors(metadata), key=lambda e: [str(p) for p in e.path]): |
There was a problem hiding this comment.
[P1] Still unbounded — re-measured at fa01a94
Unchanged since aebc405, so this is the same finding rather than a new one. Posting fresh numbers because they're from a real uvicorn process this time, not TestClient, and they're worse than what I reported before.
request body: 260,051 bytes (declared cap 262,144)
baseline GET, server idle: 0.013s
concurrent GET /agents/profiles/schema (issued +1s) waited 2.70s
concurrent GET /agents/profiles/schema (issued +2s) waited 1.70s
POST /agents/profiles/validate : 3.71s 11,328,918 bytes 130,000 findings (43x amplification)
Built with the same body as before:
items = ",".join(["0"] * 130_000)
content = f"---\nname: t\ndescription: d\nallowedTools: [{items}]\n---\n\nB.\n"Three separate costs, and the per-message _MAX_FINDING_CHARS cap at :86 addresses none of them:
- Amplification. 43x, from a request the endpoint's own
max_length=262_144declares acceptable. - Memory.
tracemallocon the same request reports 469,403,249 bytes peak. That is a single request against a 512 MiB budget, and it needs no credentials. - Event-loop stall. The two probe GETs above normally return in 13 ms and instead waited 2.70 s and 1.70 s — they were blocked until this synchronous validation finished inside the
async defhandler. Every client on the server is stalled, not just the caller.
Both producers still need the budget, not just this line. The allowedTools loop at :390 reproduces the same shape independently — 26,000 unknown string entries in a 182,051-byte request gives 26,000 warnings and a 4,160,027-byte response, so capping only the schema errors leaves the second path open.
And the cap has to bite before materialization. Slicing after sorted(...) keeps the allocation; the sort has already consumed the whole iterator by then. That's why I suggested islice(validator.iter_errors(metadata), remaining + 1) — the + 1 is what tells you whether to emit the omission marker without exhausting the iterator to count what you dropped.
The full sketch is on review 4966891645 and still applies verbatim. The one thing I'd add now that I've measured the stall: even with the budget in place, this whole function is synchronous work in an async def, so it's worth an await asyncio.to_thread(validate_profile_text, request.content) at api/main.py:2359 regardless. A bounded validation is fast enough that it probably doesn't matter, but the handler shouldn't depend on that being true.
|
@haofeif Thanks for catching this. Addressed in 4bf7c2e. Validation findings now share a request-wide limit of 100, including findings from the custom additionalProperties validator. Traversal stops as soon as the budget is exhausted, preventing request-sized CPU and memory amplification while preserving deterministic document order. I added regression coverage for: Aggregate request-wide finding limits Validation is green: 225 focused tests passed |
haofeif
left a comment
There was a problem hiding this comment.
Thank you @sujoydc! Re-reviewed at 4a6e8fde (was fa01a941). The P1 is fixed. Approving.
The PR's own new work here is 4bf7c2e fix(profile): Bound validation findings — the rest of the range is merged main (#658, #647).
The P1 is closed — re-measured, same payload as before
| metric | fa01a941 |
4a6e8fde |
|---|---|---|
| findings returned | 130,000 | 100 |
| serialized response | 11,328,918 B | 9,912 B |
| amplification | 43x | 0.04x |
| peak allocation | 469 MB | 46 MB |
| validate() wall time | 3.71 s | 1.93 s |
Same 260,029-byte request. The last finding is now error :: Additional validation findings omitted., so truncation is reported rather than silent — and the marker is reserved up front, so it can always be emitted within both ceilings.
You took the islice(...)-before-sorted(...) route at profile_validator.py:467, which is the part that actually mattered: the iterator is no longer drained, so the sort allocation is bounded too.
The residual is now inherent, not the validator's. Profiling what is left:
frontmatter.loads (YAML parse) : 0.35s
_structural_bound_finding : 0.03s
_collect_non_string_keys : 0.03s
islice(iter_errors, 100) : 0.00s (100 errors)
The validator's own work is ~0.06 s. Everything else is parsing a 256 KB YAML document, which any validator of this endpoint has to do.
I checked the bound holds across payload shapes, not just the one I built, since bounding one dimension often leaves another open:
flat 130k items 260,026B 0.34s 100 findings
nested 2000 deep 4,022B 0.01s 1 findings
alias bomb 22 lvls 477B 0.00s 1 findings
20k distinct keys 257,798B 0.14s 1 findings
250KB scalar 250,032B 0.00s 1 findings
The 22-level alias bomb at 0.00 s confirms the earlier memoization work is unregressed by the collector rewrite. Over-cap input is still rejected (300 KB content -> 422).
Correcting my own earlier recommendation on asyncio.to_thread
My previous review asked for to_thread on the handler alongside the shared budget. You did the budget and not the offload, so I tested whether the offload is still owed rather than just re-asking for it — and my recommendation was only half-right. I patched to_thread in locally and A/B'd against a live uvicorn:
without to_thread with to_thread
baseline /health ~55 ms ~60 ms
under 4 concurrent clients 1457 ms median 1138 ms median
single-request stall 367 ms 159 ms
It helps, but it does not fix the degradation, because the remaining cost is the pure-Python YAML parse holding the GIL — moving it off the event loop doesn't stop it serializing. So to_thread is a partial mitigation I should not have presented as the remedy. Not asking for it as a condition of merge.
Why the residual isn't blocking
The degradation needs a client that can repeatedly POST 256 KB. A browser page cannot:
Origin=None -> 200
Origin=http://evil.com -> 403 BLOCKED
text/plain + evil Origin -> 403 (simple-request escape also blocked)
OriginCheckMiddleware covers this route, including the text/plain no-preflight trick. What is left is a local non-browser process, which already has more direct options against a loopback server. Meaningfully lowering it would mean dropping the 256 KB request cap or rate-limiting — a broader call than this PR, and worth its own issue if you think it's warranted.
On the custom additionalProperties keyword
This was the part I was most wary of — overriding a jsonschema keyword to get document order is the kind of change that quietly alters validation results. It holds up:
Order is hash-seed independent (this is what makes a bounded prefix reproducible):
PYTHONHASHSEED=0 mcpServers.srv0000, srv0001, srv0002, srv0003 ... count 100
PYTHONHASHSEED=1 (identical)
PYTHONHASHSEED=42 (identical)
PYTHONHASHSEED=12345 (identical)
Semantics are unchanged. I diffed the full error set from the patched validator against stock Draft202012Validator across 10 documents — object-valued and boolean-valued additionalProperties, nested failures, both MCP anyOf arms, and valid docs:
bad mcp values stock=3 patched=3 identical=True
nested mcp bad stock=2 patched=2 identical=True
unknown top key stock=1 patched=1 identical=True
bad tags+caps stock=4 patched=4 identical=True
url mcp ok stock=0 patched=0 identical=True
mcp missing both stock=1 patched=1 identical=True
...
MISMATCHES: 0
Keeping the default implementation for boolean-valued additionalProperties is the right call — that's where the single aggregate error and its wording live.
Remaining threads
Both still-current threads are fixed and can be resolved — re-confirmed at this head:
profile_store.delete_profilenow goes throughlocked_atomic_delete(target), sharing the lock withreplace_profile.- URL-based MCP servers are accepted:
url-based errors=0,command errors=0, and{env: {}}still correctly rejected as satisfying neitheranyOfarm.
The outdated threads on api/main.py (scope check) and the earlier validator findings were addressed in prior commits.
179 passed across test/services/test_profile_validator.py, test/api/test_api_profile_surface.py, test/services/test_profile_store.py. One non-blocking note inline.
| for error in sorted(validator.iter_errors(metadata), key=lambda e: list(e.path)): | ||
| remaining = collector.remaining_regular_slots | ||
| sampled_errors = list(islice(validator.iter_errors(metadata), remaining + 1)) | ||
| omitted_schema_errors = len(sampled_errors) > remaining |
There was a problem hiding this comment.
[nit] The + 1 lookahead is load-bearing — worth a test that pins it.
This is the detail that makes truncation detectable without draining the iterator: you take remaining + 1, and len(sampled_errors) > remaining is then the only evidence that a tail existed. It's correct, and it's the reason the marker never fires spuriously on a document with exactly remaining errors.
Because it's an off-by-one that would fail silently in the quiet direction — drop the + 1 and truncation simply stops being reported, with every visible finding still correct — it's the kind of thing a future refactor can undo without any test going red. A pair of cases pinning both sides of the boundary would lock it in:
# exactly _MAX_FINDINGS - 1 schema errors -> no marker
# exactly _MAX_FINDINGS schema errors -> marker present, exactly onceI verified the current behaviour is right at 100 findings (marker present, exactly one, last position), so this is purely about keeping it right.
There was a problem hiding this comment.
Thanks @haofeif , will work on this nit in the next PR
…egration tier - examples/ops-mcp/run.py: use structured_content/is_error (snake_case attribute access) instead of camelCase; pydantic aliases accept camelCase for construction but attribute access requires snake_case. - test/services/test_profile_validator.py: move hash-seed determinism test (requires subprocess.run) to a new integration-tier companion file to satisfy G2 tier guard. - test/tier-census.json: register the new integration-tier test. Fixes 2 genuine failures introduced by upstream PRs awslabs#647 and awslabs#585.
The schema-error sampler takes islice(remaining + 1); the extra entry is the only evidence an omitted tail existed. Dropping the + 1 would fail silently: every visible finding stays correct while truncation simply stops being reported. Pin both sides of the boundary so a refactor that loses the lookahead turns a quiet regression into a red test. - exactly _MAX_FINDINGS - 1 schema errors -> no omission marker - exactly _MAX_FINDINGS schema errors -> marker present exactly once, last, at error severity Verified by mutation: removing the + 1 fails the second test. Ref: #585 (comment)
What
The write half of the profile HTTP surface for #510, plus the service changes it
needs and a fix for the P3 finding on #575.
No UI. That is the next and final PR on this issue.
This closes four of the five contracts @haofeif asked for on #510 (points 1, 2,
3 and 5) and the P3 comment from #575. Point 4, schema completeness, landed in
#575 with a disclosed remainder repeated under Known gaps below.
Review round 1
@haofeif and @fanhongy independently reviewed the first revision and converged on
the same two blocking defects. All four findings are fixed in follow-up commits on
this branch. I reproduced each one before fixing it rather than taking it on
trust, and two turned out worse than reported.
GET /{name}/sourcehad no scope gaterequire_any_scope(READ, WRITE, ADMIN)+ structural and enforcement testsdelete_profiledid not take the write locklocked_atomic_delete; deletion moved under the same lockDELETEscope contradicted #510WRITE, ADMINreplace_profilemissing from__all__Two things I want to surface rather than bury, because both are mine and neither
was caught by the tests I wrote:
The
replace_profiledocstring asserted that enforcing existence inside the lockcloses the update-versus-delete window. It described the hazard accurately and
then left
delete_profileunlocked, so the hazard simply moved to the other sideof the pair. A docstring promising a guarantee the code does not deliver is worse
than an undocumented gap.
The concurrency test was named
test_replace_profile_lets_exactly_one_concurrent_deleter_or_writer_win, but itsbody deleted the file before the barrier and then raced two writers. It never
overlapped a delete with a write, so the name claimed coverage that did not
exist. @fanhongy caught exactly this.
The scope reversal is discussed in full under Scope guards. Short version: I
justified admin-only on a six-of-seven precedent, but scopes are a flat set, so
admin-only 403s a
cao:writeclient rather than merely adding admin access, and#510 already published write-or-admin as the contract.
Why
#575 gave the profile surface a shared validator and a read-only validate route.
Nothing can yet create, edit or delete a profile over HTTP, so the Web UI still
cannot manage profiles at all, which is what #510 is for.
Doing that safely needs more than three route handlers. Three of the four write
contracts are properties of the service layer, not the HTTP layer, and getting
them wrong is silent rather than loud.
What changed
replace_profileinservices/profile_store.py, the update-onlycounterpart to
write_profile.This is the substantive one.
write_profile(..., overwrite=True)is an upsert,which is the wrong primitive for a
PUT. A request naming a built-in orprovider-managed profile would not fail: it would create a new local-store file
that shadows the original, silently changing which profile wins on load. That is
exactly the condition
duplicated_inwas added to surface in #523, so an upsertwould manufacture the thing we warn about.
Only one function is added.
write_profile(..., overwrite=False)is alreadycreate-with-409, and #543 documented it as the one supported way to ask for
create-without-clobber, so a
create_profilealias would be churn.must_existonlocked_atomic_write, enforced inside the same criticalsection as
overwrite:Same reasoning as the
overwriterace we fixed in #543. A caller testing for thefile beforehand would leave a window where a concurrent delete turns an intended
update back into a create.
overwrite=Falsewithmust_exist=Trueiscontradictory and raises
ValueErrorrather than always surfacing asFileExistsError, which would mislead the caller into thinking the file was inthe way.
locked_atomic_delete, also inatomic_file.py. Added during review, and itis the other half of the guarantee above rather than a nicety.
must_existisonly meaningful if deletion takes the same lock.
delete_profilewas doing anunlocked
exists()thenunlink(), so this interleaving was reachable:replace_profiletakes the lock and passes itsmust_existcheckdelete_profileunlinks the file and reports successreplace_profilepublishes, recreating what was just deletedBoth callers were told they succeeded and the deleted profile was back on disk
holding the replacement text. Reproduced deterministically with a barrier, and
the reproduction now shows the deleter blocked on the lock instead.
unlinkisalready atomic, so the helper adds only the lock, not atomicity. It is safe
against the
flock-is-per-inode hazard because the lock file is not the target:_lock_path_forkeys a file underLOCK_DIRby a hash of the resolved path, andthose are never unlinked, so removing a target leaves the lock inode intact.
The docstring I shipped on
replace_profileclaimed the enclosing guaranteewhile
delete_profilewas still the unlocked side of it, and the concurrencytest was named for a deleter it never exercised. Both are corrected.
Built-in protection falls out of the service boundary, which is what point 3
asked for.
profile_storeresolves only insideLOCAL_AGENT_STORE_DIR, so abuilt-in's name is simply not there, and
must_existtherefore rejectsPUTagainst a built-in under the lock. No handler-level check, no separate list of
protected names.
A shared
_validate_profile_for_writehelper inapi/main.py, used by bothPOSTandPUTso the two cannot drift apart. It runs the #575 validator on theexact document being persisted, rejects error-severity findings with 400, and
returns warnings for the response. It also enforces the name rule (below).
Two details in it are deliberate rather than incidental.
It parses the frontmatter once. The obvious implementation calls
validate_profile_text(content), but that parses internally and the helper needsthe metadata anyway for the name check, so the document would be parsed twice.
validate_profile_text's docstring exists specifically to stop callersduplicating that parse, so the helper parses once and calls
validate_frontmatter(metadata)instead.Every 400 from a write route uses one
detailshape:{"message": "...", "errors": [{"severity": "error", "message": "...", "path": "engine"}]}errorsis empty for failures that are not attributable to a field, such asunparseable YAML, but the key is always present so a client can iterate it
unconditionally. Without this, one endpoint returned a dict for a schema failure
and a bare string for a name mismatch or a parse failure, forcing a client to
switch on
type(detail). The Web UI is that client, so this would have becomeits problem.
This covers the 400s, which are the ones carrying per-field findings. The service
error mappings (404 for a missing target, 409 for a conflict) keep FastAPI's
conventional bare-string
detail, since the status code already tells a clientwhat happened and there are no findings to attach.
Three write routes and one authoring read. Scope guards mirror the existing
conventions rather than inventing one; see below.
The #575 P3 fix, folded in because point 5 makes it load-bearing: once
POSTand
PUTrun the validator before persisting, a crash there 500s a write pathrather than a pure read.
On the name-identity rule (point 2)
A profile has two identities: the storage key (its filename stem) and the
frontmatter
name.parse_agent_profile_texttreats the stem only as a fallbackwhen frontmatter omits
name:So
name: fooinbar.mdloads asfoowhile being addressed asbar, andnothing reconciles them. Both write routes now require the two to agree, with a
400 on mismatch.
POSTtakesnameexplicitly in the body rather than parsing it out ofcontent, so the 409 target is unambiguous even when the document is malformed.PUTtreats the path parameter as authoritative.Rename is deliberately not implemented. Your point 2 offers "require them to
match for create/update, or define an explicit rename operation"; this takes
the first branch. Rename is delete-plus-create with its own failure semantics
(partial failure, mid-rename collision, whether references follow) and deserves
its own design rather than riding along here. #510's title covers search, create,
edit, delete and validate, not rename.
On the unresolved authoring read (point 1)
Agreed, and the problem is worse than losing placeholders.
load_agent_profilecallsresolve_env_vars(raw_text)on the raw text beforeparsing, so substitution reaches the Markdown body as well as the frontmatter,
and the substitution source is the managed CAO
.envfile. An edit round-tripthrough
PUTwould therefore write resolved secret values into a plaintextprofile in the local store.
safe_substituteleaves unset variables intact,which makes the damage selective and silent: only the variables a user actually
configured get baked in.
GET /agents/profiles/{name}/sourcereturns the document verbatim.Note on the implementation: it calls the existing
_read_agent_profile_source. That function is named private but already hasimporters in three modules (
cli/commands/profile.py,install_service.py), soit is a de facto public API with a private name. I deliberately did not rename
it here: it is already HTTP-reachable through
GET /agents/profiles/{name}, so asecond caller adds no new exposure, and renaming would mean four call-site edits
in files this PR otherwise does not touch. Worth doing as its own small change.
Scope guards
All three write routes mirror
POST /agents/profiles/install:DELETEincluded. It wasSCOPE_ADMINalone in the first revision and waschanged during review. Two reasons, and the first is the decisive one.
Scopes here are a flat set, not a hierarchy.
require_any_scopetestsmembership and
get_current_scopesreturns the token's claims verbatim with noexpansion, so
cao:admindoes not implycao:writeandcao:writedoes notimply
cao:read. Admin-only onDELETEtherefore does not mean "admins canalso delete", it means a client holding exactly
cao:writeis 403'd, so theprofile-management credential this PR exists to serve could create and edit a
profile but never remove it. That contradicts the contract published in #510,
which specifies
cao:writeorcao:adminfor all three.Second, the precedent I originally leaned on splits differently than I claimed:
DELETE /sessions/{session_name}SCOPE_ADMINDELETE /terminals/{terminal_id}SCOPE_ADMINDELETE /workflows/{name}SCOPE_ADMINDELETE /flows/{name}SCOPE_ADMINDELETE /memory/{key},DELETE /memorySCOPE_ADMINDELETE /memory/relationships/{id}SCOPE_WRITE, SCOPE_ADMINSix of seven do use admin alone, but every one of those removes running or
generated state. The lone write-or-admin exception is the only content
resource in the list. A profile is an authored document: removing one stops no
in-flight work, destroys nothing that cannot be re-authored, and is already
gated behind
ConfirmModalin the UI. It belongs with the relationship delete,not with the session teardown.
Six enforcement tests assert the guards are real rather than merely declared: a
cao:readtoken is 403'd on create and on delete, whilecao:writeis admittedon all three and
cao:adminon delete.These are real mutations, so there are no
_EXEMPTentries, unlike #575'svalidate route. The
_EXEMPTset intest/api/test_scope_coverage.pyisunchanged.
The read route is gated too
GET /agents/profiles/{name}/sourceshipped with no scope dependency in thefirst revision. That was a real hole and it is now
require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), the identical shapeall ten already-guarded
GETroutes use.Worth stating why the repo-wide picture did not excuse it. 29 of 39
GETroutescarry no gate, so my first instinct was that this matched convention. But the
ten that are gated are the sensitive reads (
/memory/export,/memory/relationships,/outcomes,/workflows/runs/{run_id}/result), andthis repo already settled the exact question during the #505 review: the new read
routes got the gate while their pre-existing ungated siblings were deliberately
left alone, because tightening a shipped route risks breaking an existing
unauthenticated reader. I have followed that split, so the five pre-existing
profile reads are untouched.
test_scope_coverage.pydid not catch this:_MUTATING_METHODSis{POST, PUT, PATCH, DELETE}, so a new ungatedGETis invisible to it. The newguard therefore comes with a structural test asserting the dependency exists on
the route object, mirroring
_NEW_505_READ_ROUTES. A status-code test wouldhave been worthless here, and the #505 test says so in its own docstring: auth is
default-off, and
require_any_scopehands back the full scope set when it isoff, so "the route returns 200" passes whether or not the dependency exists at
all. That is precisely how this shipped ungated.
Route ordering
GET /agents/profiles/{name}/sourceis declared afterGET /agents/profiles/{name}, which is safe because the extra path segmentcannot be captured by a single
{name}parameter. There is a test asserting thetwo return different shapes rather than one serving the other, so a future
refactor that collapses them fails loudly.
The #575 P3 fix
Three malformed-but-parseable YAML shapes raised
TypeError, which is not caughtby the handler's
except ValueErrorand therefore surfaced as HTTP 500 from aroute whose entire job is reporting what is wrong with a document:
The two advisory checks test set membership, which hashes the value; the schema
error sort used raw path components, which cannot be ordered across types. Fixes:
stringify the sort key, and type-guard both advisory checks per value so the
schema owns the type error. All three now return 200 with
valid: falseand theschema error attached, which is the correct answer since the schema already
rejects them.
Behaviour changes, disclosed
1.
GET /agents/profiles/{name}'s docstring now points at the source route.No behaviour change, but the resolved-versus-source distinction was previously
undocumented and is easy to get wrong.
2. Both write routes bound
contentat 256 KB via Pydanticmax_length,matching #575's validate route. Generous for a profile, and it avoids an
unbounded parse. Happy to drop the cap.
3. Malformed values that previously crashed now produce different text. They
were already rejected, as unknown keys or unhashable values; now the message
names the actual problem with a path a form can render against.
Deliberate choices worth flagging
Three things a reviewer is likely to question. Each is a decision, not an
oversight, so here is the reasoning up front.
PUTan invalid document to a missing profile returns 400, not 404.Validation precedes persistence, so the malformed body wins over the absent
target:
Defensible either way. I kept validation first because reordering means an
existence pre-check outside the write lock purely for error ordering, and that
reintroduces a TOCTOU-shaped code path a future reader could mistake for the real
guard. The authoritative existence check has to stay inside the lock.
InvalidProfileNameErroronPOSTis probably unreachable. The schema'snamepattern rejects unsafe names beforewrite_profilesees them, and arequest-name / frontmatter-name divergence trips the mismatch check first. Kept as
defence in depth: the schema pattern and
profile_store._PROFILE_NAME_REareindependent guards that could drift apart. Happy to drop the handler if you would
rather not carry an unreachable branch.
The read and write paths validate names with different strictness.
agent_profiles._validate_agent_namerejects only/,\and.., whileprofile_store._PROFILE_NAME_REenforces[A-Za-z0-9_-]{1,64}. Sobad@namereturns 400 on
DELETEand 404 on/source. This asymmetry is pre-existingbetween those two modules and already applies to
GET /agents/profiles/{name};the new source route only makes it visible on one more route. Tightening it would
change the existing read route's behaviour, so it does not belong here. Traversal
is blocked on both paths, so this is a consistency wart rather than a security
gap.
Known gaps, disclosed
Point 4 is still only half closed. #575 fixed field presence by adding
containerandprovider_init_timeout. Field shape is unfinished:toolsSettings,codexConfigandhooksremain bare{"type": "object"}withno properties, so a form generated from
/agents/profiles/schemacannot renderthem as inputs. The UI PR plans validated JSON editors for the object-valued
fields for exactly this reason.
The Markdown body has no explicit round-trip contract. The body is the system
prompt. These routes persist it verbatim and the source route returns it
verbatim, which is the behaviour an editor needs, but the schema still describes
frontmatter only.
No rename, as described above.
Explicitly out of scope
_read_agent_profile_source. Its own change.since it touches the schema rather than the write path.
Testing
Full suite: 6,389 passed, 39 skipped, 111 deselected, 1 xfailed.
My local environment also has 62 failures, confined to
test/api/test_agui_*,test/services/agui/,test/telemetry/test_otel_init.py,test/test_no_ffi_guard.py, and an intermittenttest/services/test_fifo_reader.py.Same count and per-file distribution measured on
mainwith this branch's changesabsent, and none of those modules import profile code. Flagging rather than
omitting, since I can't confirm how they behave in CI.
Counts from
pytest --collect-onlyagainst both this branch and a cleanorigin/mainworktree, not inferred:test/api/test_api_profile_surface.pytest/utils/test_atomic_file.pytest/api/test_scope_coverage.pytest/services/test_profile_store.pytest/services/test_profile_validator.py58 net new tests. An earlier revision of this description said 53, which was
wrong twice over: its own table summed to 48, and the total had been carried
across an edit rather than recomputed. The figures above are re-measured on both
trees.
The ones worth calling out:
PUTandDELETEagainstcode_supervisor, a real shipped built-in, assert404 and that no local file was created. This is the point-3 regression
guard, at both the service and HTTP layers.
DELETEoverlapped with an in-flightPUT, pausing inside the publish so thewindow is deterministic rather than scheduler-dependent. The deleter must still
be blocked on the lock while the replace holds it, which is the assertion that
fails on the unlocked implementation.
must_existholds under contention: bothconcurrent updaters of an absent target are refused and neither creates it.
Named for what it does now; it previously claimed deleter coverage it did not
have.
locked_atomic_deleteis asserted to contend on the same lock as the writers,by holding that lock externally and requiring the delete to time out. A delete
that keyed a different lock, or took none, would return immediately.
route object, plus enforcement tests that a scopeless token is 403'd and a
cao:readtoken is admitted.PUTleaves the existing file byte-identical, so validationgenuinely precedes persistence rather than running alongside it.
block/allow contract the UI depends on.
${MY_TOKEN}unresolved, the point-1 guard.detailshape, so the unified error contract cannot regress silently.
black --check src/ test/andisort --check-onlyclean across 536 files.mypyon both changed service files: no issues.Exercised against a running server
Every endpoint test monkeypatches the store, so the routes were also driven over
real HTTP against
cao-serverwithCAO_HOME_DIRpointed at a throwawaydirectory. Six checks, all as expected.
Happy path:
The
sourceresponse is the point-1 evidence. The stored document containedToken: ${MY_TOKEN}and came back with the placeholder intact:{"name": "probe", "content": "---\nname: probe\ndescription: Local check.\n---\n\nToken: ${MY_TOKEN}\n"}GET /agents/profiles/probewould have returned that substituted from the managedenvironment file, and writing it back would have persisted the resolved value.
Built-in protection, the point-3 guard:
code_supervisoris a profile that ships with the package. A 200 there would meana local file had been created that shadows it on load.
Unified rejection shape, two different causes on the same route:
Same keys either way, and the field-level failure carries
path: "engine"so aform can render the error against the right input.
What comes next
The UI: a Profiles panel and nav tab consuming this surface, with inline
validation through
POST /agents/profiles/validate, a from-scratch form driven byGET /agents/profiles/schema, and clone-to-customise for built-ins.Ref #510. Builds on #523 (read surface), #543 (
profile_store,locked_atomic_write) and #575 (validator service, validate and schema routes).